preact源码 - VDOM

March 24, 2021

前言

在工作上开始用 React 开发已经有四个多月了,不禁想看看 React 和 Vue 本质上有什么区别。当然一个是 jsx 文件,一个是 vue 文件,两个处理起来肯定是不一样的。想了想以后项目发展越来越大,肯定是要以 React 为主体的,深入了解 React 是必须的,尤其是 React 已经发展到 React 16 了,新特性都不晓得怎么用呢。为了减少初学习 React 源码的陡度,想着还是从 Preact 开始好了,毕竟后者声称兼容 React 而且,关键是体积小!

Babel 与 JSX

在进入 Preact 的介绍前,又必须要说说虚拟 DOM,这虚拟 Dom 听着神奇,在 Vue 里面也主角。所以必要介绍一下。

在 React 官网的开始学习教程上有下面这段代码

ReactDOM.render(
  <h1>Hello, world!</h1>,
  document.getElementById('root')
);

刚看到时候可以很明显的发现这是给 ReactDOM.render 传入两个参数,一个是 H1 标签,一个是 DOM 节点,后者很好理解,就是最平常的获取一个 id 为 root 的 DOM 节点,可是前者又是什么?在 Javascript 的所有类型里面是没有这种东西的,难不成是自己落后了?于是在 Chrome 的控制台里面打印,看一下,来一发 console.log(<div></div>),结果立马返回 Uncaught SyntaxError: Unexpected token < 这就提示说语法错误了,那是那里出错了呢?试试了试在 Preact 的 index 页面打印 console.log(<div></div>),代码却能够正常跑起来,而且 console.log(typeof <div></div>) 居然是 'object' 奇了怪了?

难道两处代码是不一样的?难不成 Preact 做了什么特别处理?随查看 Preact 的源码,但是没有任何迹象,传入的参数根本就没有做什么处理,而且传进来马上就会语法错误了,怎么可能执行呢?

最后打开控制台,查看 Sources 的打包文件,发现原来 console.log(typeof <div></div>) 变成了下面:

console.log(_typeof((0, _preact.h)('div', null)));

// 转变一下结果就是
console.log(typeof preact.h('div', null));

这。。。。。又是为什么呢?中间怎么这么多变化呢?在生成的代码阶段就不是简单的 div 了,那是哪里发生的呢?忽然想起以前讲 webpack 介绍的 Babel 降级问题,难道这里也是?查看 Babel 的配置文件 .babelrc,如下:

{
  "presets": ["es2015", "stage-0"],
  "plugins": [
  ["transform-react-jsx", { "pragma": "h" }]
  ]
}

看看下面的配置,在看看转义的那句话,这不就是将 jsx 用 h 程序转变的意思吗?在 package.json 里面也看到了 babel-plugin-transform-react-jsx,这个包是用于将 JSX 转换为 React 函数,用法则是在 .babelrc 里面设置 "pragma": "dom",后者是替换的函数名字,默认是 React.createElement,而在 Preact 中则需要设置为 h 函数。官网里面也有介绍到对于 Babel 5 和 Babel 6 的设置。

h 函数

上文中的 div 变成了 h 函数的实现,h 函数在 Vue 里面也经常可以看到,更不要提 React,那 h 函数是什么呢?

export function h(nodeName, attributes) {
  let children=EMPTY_CHILDREN, lastSimple, child, simple, i;
  for (i=arguments.length; i-- > 2; ) {
    stack.push(arguments[i]);
  }
  if (attributes && attributes.children!=null) {
    if (!stack.length) stack.push(attributes.children);
    delete attributes.children;
  }
  // 存在子节点,如text节点或者其他h函数
  while (stack.length) {
    if ((child = stack.pop()) && child.pop!==undefined) {
      for (i=child.length; i--; ) stack.push(child[i]);
    }
    else {
      if (typeof child==='boolean') child = null;

      if ((simple = typeof nodeName!=='function')) {
        if (child==null) child = '';
        else if (typeof child==='number') child = String(child);
        else if (typeof child!=='string') simple = false;
      }
    // 最终子节点都会推入children里面
    // 而对于简单节点则直接相加就好了
      if (simple && lastSimple) {
        children[children.length-1] += child;
      }
      else if (children===EMPTY_CHILDREN) {
        children = [child];
      }
      else {
        children.push(child);
      }

      lastSimple = simple;
    }
  }
  // p就是最终生成的 Vnode
  let p = new VNode();
  p.nodeName = nodeName;
  p.children = children;
  p.attributes = attributes==null ? undefined : attributes;
  p.key = attributes==null ? undefined : attributes.key;
  // 对生成的Vnode,都用options的vnode方法来处理
  // if a "vnode hook" is defined, pass every created VNode to it
  if (options.vnode!==undefined) options.vnode(p);

  return p;
}

export function VNode() {}

上面代码还是很好理解的,下面举个简单例子,对于 h('DIV', {id: 'abc'}, h('SPAN', null)) 怎会被转换为以下 Vnode:

{
  nodeName: 'DIV',
  attributes: {id: 'abc'},
  children: [
  {
    nodeName: 'SPAN',
    attributes: undefined,
    children: null,
    key: undefined,
  }
  ]
  key : undefined,
}

h 函数里面的 while 循环里面做了一件很特别的事情,本来只要将子节点统统 push 到 children 里面就好了,但这里通过相邻节点是否是简单方式 simple/lastSimple,若果是数字,字符串等,则直接合并在一起。这样有什么好处呢?减少要 diff 的节点,就是减少计算量,毕竟虚拟 Vnode 里面主要内容也是字符串等简单类型。而 VNode 构造函数,则是简单的一个实例而已。

总结

这里介绍了 VNode 的一些入门东西,但是这是后面学习 diff 机制的基础,也能够清晰知道 Preact 的操作对象不是 Document 上面的节点,而是一个个虚拟的 VNode,